内存优化 —— bitmap pool
前言
图片是 Android 中占用内存比较大的模块,对于一张使用 ARGB_8888(一个像素低占用32位,即4个字节),480 x 800 大小的 bitmap 来说,内存就要占用 480 x 800 x 4 / 1024 = 600kb 的内存大小,就算使用 RGB_565 也还是需要 300kb 的内存,而且失去了透明度和一些细节。尤其是针对一些图片列表,图片的缓存尤其重要。
通常我们知道那些著名的第三方图片加载框架用 LRU Cache 来缓存图片,防止对一张已经加载过的图片又加载一遍,但是很少有人关心过当图片移出 Cache 之后发生了什么。当加载场景是一个无限滑动的图片列表时,基本不会出现图片的复用,系统开始频繁创建销毁 bitmap 资源对象,造成了比较大的内存抖动。所以拥有一个可以复用的对象池可以极大地优化上述现象,介于 bitmap 的特殊性,这个需要特殊定制优化,以 Glide 为例。
本文基于 Glide 4.3.1 代码
基础知识
Android 官方有一篇关于 Bitmap 内存管理的文章 Managing Bitmap Memory
Android 各个版本中对于 bitmap 内存优化如下:
- Android 2.2(API 8)及以下,当垃圾回收触发时使导致整个应用暂停,这种同步的回收会影响性能表现。Android 2.3 加入了并发回收策略,如果 bitmap 没有被引用将会很快被回收
- Android 2.3.3(API 10)及以下,bitmap 的像素数据存在于 native heap 中,而 bitmap 本身则存在于 Dalvik heap 中。由于 native 内存中的像素数据何时被释放无法预测,所以可能会导致应用内存溢出而崩溃。从 Android 3.0(API 11)到 Android 7.1(API 27),像素数据被移到了 Dalvik heap 中,和 bitmap 一样。而 Android 8.0 中,像素数据又再一次被移到了 native heap 中。可能是改进了回收策略,防止像素数据存在 Dalvik heap 中时,使用大量 bitmap 的场景会导致虚拟机的堆内存溢出。
同时,对于优化 bitmap 的内存管理措施,不同版本也有区别
- Android 2.3.3(API 10)及以下推荐使用 recycle() 来回收 bitmap 的内存
- Android 3.0(API 11)开始,引进了 BitmapFactory.Options.inBitmap 选项,如果设置了此选项,那么在加载 bitmap 时,会采用 Options 对象的解码方法尝试重新使用现有的 bitmap。这意味着 Bitmap 的内存被重用。这样做不但能提高性能,而且省去了内存分配和解除的过程。但是,如何使用 inBitmap 有一定的限制。特别是,在Android 4.4(API 19)之前,只支持相同大小的 bitmap,而在 4.4 以后,只需要复用的 bitmap 大于等于被解码的 bitmap 即可
所以在开发 bitmap 的复用池时,一定要注意各个版本的区别,值得庆幸的是现在 Android 3.0 版本以下的基本可以被忽略了,所以我们只需要考虑不同版本的 bitmap 复用策略即可
解析
GlideBuilder 配置选项中提供了 setBitmapPool 的选项,可以传入一个自定义实现 BitmapPool 接口的对象。当然,Glide 提供了默认的实现对象
1 | if (bitmapPool == null) { |
LruBitmapPool
LruBitmapPool 的类描述如下:
An BitmapPool implementation that uses an LruPoolStrategy to bucket Bitmaps and then uses an LRU eviction policy to evict Bitmaps from the least recently used bucket in order to keep the pool below a given maximum size limit.
使用 LruPoolStrategy 管理 bitmaps,通过 LRU 策略维持对象池的大小
LruBitmapPool 的主要成员变量如下:
1 | // 默认的图片配置时 ARGB_8888 |
LruPoolStrategy 一般通过 Bitmap 的 height,width 以及 Bitmap.Config 来生成缓存键。缓存池的大小控制则是通过 currentSize 和 maxSize 的比较来决定是否需要移除 LruPoolStrategy 中的对象。
在 getDefaultStrategy() 方法中,已经有两个系统默认的实现,而之所以会有两个实现,是因为上文提及 Android 不同版本复用策略不同导致的
1 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { |
SizeConfigStrategy 的实现相较于 AttributeStrategy 的实现更为复杂,我们以 SizeConfigStrategy 为例深入解析
SizeConfigStrategy 的结构并不复杂,但是运用到了比较多的数据结构
SizeConfigStrategy
1 | // 用来缓存键的对象池,内部为一个 20 长度的队列,key 的运用非常频繁,需要创建对象池管理 |
关于这些变量可以结合 get 方法去详细解析
SizeConfigStrategy.get 方法
1 | public Bitmap get(int width, int height, Bitmap.Config config) { |
追踪到 findBestKey 方法中去具体查看到底是如何匹配到最佳的 key 以及 sortedSizes 是如何工作的
1 | private Key findBestKey(int size, Bitmap.Config config) { |
这就是大致的逻辑,可以获取到大于目标 bitmap 大小的最接近的可复用的 bitmap,而 put 方法就相对比较简单,只需要往 groupedMap 和 sortedSizes 都插入数据即可,由于池大小是由外部控制,这里也无需进行额外操作。
我们回到 GroupedLinkedMap 去看看,其实 LinkedHashMap 中如果开启了 accessOrder 是可以实现类似于 LRU 的功能的,他将会把 put 或者 get 操作过的数据放到队尾,队首存放的是最少最久使用的数据。而 GroupedLinkedMap 则是针对使用场景去做了修改,GroupedLinkedMap 的类描述如下:
Similar to {@link java.util.LinkedHashMap} when access ordered except that it is access ordered on groups of bitmaps rather than individual objects. The idea is to be able to find the LRU bitmap size, rather than the LRU bitmap object. We can then remove bitmaps from the least recently used size of bitmap when we need to reduce our cache size.
For the purposes of the LRU, we count gets for a particular size of bitmap as an access, even if no bitmaps of that size are present. We do not count addition or removal of bitmaps as an access.
在 GroupedLinkedMap 中存放数据的是相同 key 下的一堆 bitmap 而不是单个的 bitmap 对象,具体表现为 LinkedEntry 中存放的是 List
GroupedLinkedMap.get 方法
1 | public V get(K key) { |
而 LinkedLinkedMap.get 方法则不同
1 | public V get(Object key) { |
如果目标为空,则不会对链表进行操作
GroupedLinkedMap 移除就是常规的链表队尾移除操作,会先移除 Entry 中 list 里的数据,移除完以后才会将 Entry 移除
AttributeStrategy
而 AttributeStrategy 中则不需要那么复杂的逻辑,因为他只需要 width, height, config 完全对应即可,那就简单的多了。具体逻辑可以自己去看
结语
对于频繁创建对象的场景有必要使用对象池去优化,而 bitmap 由于复用的特殊性需要区分版本对待。日常使用中,推荐使用 Glide 这种第三方库去加载图片,因为其内部实现了三级缓存,如果非要自定义的话,在考虑到 LRU 的同时也要考虑到 bitmap 的复用